查看原文
其他

好奇了好久的「对象」,就这?

脚本之家 2022-04-23

The following article is from 编程指北 Author 编程指北

 关注
脚本之家
”,与百万开发者在一起

作者 | 编程指北
来源 | 编程指北(ID:cs_dev)
别误会,今天要写的不是我对象,毕竟我还没有......
这篇文章主要是聊聊我对于编程语言中「对象」的一些简单认识,

面向过程 VS 面向对象

为什么 C 叫面向过程(Procedure Oriented)的语言,而 Java、C++ 之类叫面向对象(Object Oriented)呢?
之前听到一个有趣的说法:
在 C 语言中我们是这样写代码的:
function_a(yyy);function_b(xxx);
从左往右看过去,最先看到的是函数,也就是 Procedure,故叫做 Procedure Oriented。
而在 Java 这类语言我们通常是这样的:
Worker worker = new Woker("小北");worker.touchFish("5分钟");worker.coding("1小时");
第一眼看到的就是一个个的对象,所以叫做面向对象 Object Oriented。
回到正题。在 C 语言中,数据和操作数据的函数是互相分开的,你并不知道数据和函数之间有什么关联,这在语言层面上是不支持的。
在 C 语言中,编程就是将一堆以功能为核心导向的函数进行组合,依次调用这些函数就可以了。
这就叫面向过程,其实和我们思考问题的方式是吻合的。比如要实现一个贪吃蛇游戏,那面向过程的设计思路就是首先分析问题的步骤:
  1. 开始游戏
  2. 随机生成食物
  3. 绘制画面
  4. 接收输入并改变方向
  5. 判断是否碰到墙壁和食物等
  6. ...
而用面向对象的思路则是:
首先,将整个游戏拆解为一个个的实体:蛇、食物、障碍物、规则系统、动画系统。
其次,分别去实现这些实体应该具有的功能(即成员函数),同时你还要考虑不同实体之间如何交互和传递消息。说白了就是调用关系和传参。
比如规则系统接收蛇、食物、障碍物作为参数,可以判定是否吃到食物或者碰到墙壁。
动画系统则可以接收蛇、食物、障碍物等作为参数,然后在屏幕上动态的显示出来。
这样做的好处是:可以利用面向对象有封装、继承、多态性的特性,设计出低耦合的系统,使系统更加灵活、更加易于维护。
好了,上面这段大概可以看做八股文。你分别用 C 和 Java/C++ 写过程序自然知道二者区别。不然我说再多的高内聚、低耦合也没啥用。

对象如何实现?

对象的就是由一堆的属性(成员变量)和一系列的方法(成员函数)组成。在讲这个之前,先补充说明一个函数指针。
我们都知道函数在 C/C++、Java 这类语言中都不是一等公民,一等公民的意思就是能够像其它整数、字符串变量一样,可以被赋值或者作为函数参数、返回值等。但是在 JS、Python 这类动态语言中,函数却是一等公民,可以作为参数、返回值等等。
究其原因,这类语言底层实现中,一切东西皆是对象。函数、整数、字符串、浮点数都是对象,函数才因此具备同其它基本类型一样的一等公民的身份。
在 C/C++ 中,函数虽然是二等公民, 但我们可以通过函数指针来变相的实现将函数用于变量赋值、函数参数、返回值场景。

函数指针是啥?

我们知道普通变量申明后,编译器就会自动分配一块适合的内存。函数也是同样的,编译的时候会将一个函数编译好,然后放在一块内存中。
(上面这段说法实际很不准确,因为编译器不会分配内存,编译好的代码也是以二进制的形式放在磁盘上,只有程序开始运行时才会加载到内存)
如果我们把函数的首地址也存储在某个指针变量里,就可以通过这个指针变量来调用所指向的函数了,这个存储函数首地址的特殊指针就叫做函数指针
比如有一个函数 int func(int a);
我们如何申明一个可以指向 func 的函数指针呢?
int (*func_p)(int);
看起来有点奇怪,其实函数指针变量的声明格式和同函数 func 的声明一样,只不过把 func 换成(*func_p)罢了。
为什么要括号呢?因为不要括号的话 int *func_p(int); 就是申明一个返回指针的函数了,括号就是为了避免这种歧义
我们来多看几个函数指针的申明吧:
int (*f1)(int); // 传入int,返回int void (*f2)(char*); //传入char指针,没有返回值 double* (*f3)(int, int); //传递两个整数,返回 double指针
来看一个函数指针的具体用处吧:
# include
typedef void (*work)() Work; // typedef 定义一种函数指针类型
void xiaobei_work() {printf("小北工作就是写代码");}
void shuaibei_work() {printf("帅北工作就是摸鱼")}
void do_work(Work worker) { worker();}int main(void){ Work x_work = xiaobei_work; Work s_work = shuaibei_work; do_work(x_work); do_work(s_work);return 0;}
输出:
小北工作就是写代码帅北工作就是摸鱼
其实这里有点为了用函数指针而用了,不过大家应该体会到了,函数指针最大的优点就是将函数变量化了。
我们可以将函数作为参数传递给其它函数,于是就有了多态的雏形。我们可以传递不同的函数来实现不同的行为。
void qsort(void* base, size_t num, size_t width, int(*compare)(const void*,const void*))
这是 C 标准库中 qsort 函数的申明,它最后一个参数就要求传入一个函数指针,这个函数指针负责比较两个 element。
因为两个元素的比较方式只有调用者才知道,所以这里需要以函数指针的形式告诉 qsort 如何去判定两个元素的大小。
好了,函数指针就简单介绍到这里,接下来回到主题,对象。

对象

那么在 C 语言中如何简单模拟一个对象呢?
当然只能靠结构体啦,而成员函数就可以通过函数指针来实现,其它的比如访问控制、继承等我们暂时不考虑。
struct Animal {char name[20];void (*eat)(struct Animal* this, char *food); // 成员方法 eatint (*work)(struct Animal* this); // 成员方法 工作};
但是 eat 和 work 都还没有任何具体实现,所以我们可以在一个初始化函数中构造 Animal 对象。
void eat(struct Animal* this, char *food) { printf("%s 在吃 %s\n", this->name, food);};
void work(struct Animal* this) { printf("%s 在工作\n", this->name);}struct Animal* Init(const char *name) {struct Animal *animal = (struct Animal *)malloc(sizeof(struct Animal)); strcpy(animal->name, name); animal->eat = eat; animal->work = work;return animal;}
在 Init 函数内部我们就完成了“成员函数”的赋值和一些初始化工作,并且给 eat 和 work 两个函数指针都绑定了具体的实现。
接下来我们可以使用一下这个对象:
int main() {struct Animal *animal = Init("小狗"); animal->eat(animal, "牛肉"); animal->work(animal);return 0;}
输出:
小狗在吃牛肉小狗在工作
为什么明明 animal 调用的 eat 方法,却还要把 animal 当参数传递给 eat 方法呢,难道 eat 不知道是哪一个 Animal 调用的它吗?
答案是确实不知道。对象其实就是在内存中一段有意义的区域,每一个不同的对象都有各自的内存位置
而他们的成员函数却存放在代码段,并且只会存在一份副本。
所以 animal->eat(...)调用方式和直接调用 eat(...),效果完全等同,那个animal 存在的意义就是让你从面向过程转变为面向对象思考,将方法调用转变为对象间消息传递。
所以当调用成员函数的时候,我们还需要传入一个参数 this,用来指代当前是哪个对象在调用。
由于 C 语言不支持面向对象,所以我们需要手动将 animal 作为参数传递给 eat、work 函数。
如果是在 C++ 这种面向对象的语言中,我们直接不用手动传递这个参数,就像下面这样:
animal->eat(“牛肉”);animal->work();
实际上这是编译器帮我们去做这个事,上面这两行代码,经过编译器之后会变成下面这个样子:
eat(animal, "牛肉");work(animal);
然后,编译器还会在编译阶段默默地将 this 作为成员函数的一个形参添加到参数列表。
并且哪个对象调用的方法,那个对象就会被当做参数赋值给 this。
学习 Java 的的同学也一定对这个this非常熟悉吧,Java 中和 C++ 中的 this 基本都是一样的作用。
或者说,几乎所有的面向对象语言,都会存在一个类似的机制,来将调用对象隐式的传递给成员函数,比如 Python 中的对象定义:
class Stu:def __init__(self, name, age):self.name = nameself.age = age
def displayStu(self): print "Name : ", self.name, ", Age: ", self.age
可以看到每个成员函数第一个参数都必须叫 self,这个 self 实际上就是和 this是一样的作用。
只有这样,当你在成员函数内访问成员变量的时候,编译器才知道你访问的是哪一个对象。
诶,别忙,按照这样说,那岂不是,如果我在成员函数内不访问任何成员变量,就不需要传递这个 this 指针?
或者说可以传递一个空指针?
理论上确实成立,并且在 C++ 中也是可行的,比如下面这段代码:
class Stu{public:void Hello() {cout << "hello world" << endl; }private:char *name;int age;float score;};
由于,在 Hello 函数中没有用到任何成员变量,所以我们甚至可以这样玩:
Stu *stu = new Stu;stu->Hello(); // 正常对象,正常调用stu = NULL;stu->Hello() // 虽然 stu 为 NULL,但是依然不会发送运行时错误
这里实际上可以这样看:
stu->Hello(); 等价于Hello(NULL);
由于在 Hello 函数内部,没有使用任何的成员变量,所以就不需要用 this 指针去定位成员变量的内存位置,在这种情况下,调用对象为不为 NULL 其实是不重要的。
但是如果 Hello 函数访问了成员变量,比如:
void Hello() {cout << "Hello " << this->name << endl;}
这里需要用到 this 去访问 name 成员变量, 那么就会导致运行时程序发生 coredump,因为我们访问了一个 NULL 地址,或者说是基于 NULL 偏移一定位置的地址,这段空间绝对是没有访问权限的。
恰好之前也有位同学在群里问了这个问题:
这个问题的解释就和上面的一样,但是这个结论不能推广到其它语言,比如 Java、Python。这些语言的虚拟机一般会做一些额外的检查,比如判断调用对象是否是空指针等,是的话就会触发空指针异常。
而 C++ 就真的是很纯粹的编译成汇编,只要从汇编层面能跑通,那就没问题,所以才能利用这个“奇技淫巧”。
那写这篇文章的目的,就是想让大家对「对象」有一个具体的认识,最好是明白对象在内存中或者 JVM 中是如何布局的。
我以前就会觉得对象挺神奇的,一堆的功能,后来才后知后觉,这不就是一个结构体再加上编译器的语法糖吗?

(完)

  推荐阅读:

每位开发者都应了解的数据库一致性!

掌握了这30道MySQL基础面试题,我成了面霸

面向对象的两大迷思,再给你们解答一次

每日打卡赢积分兑换书籍入口


 由于微信公众号近期改变了推送规则,如果你想如常看到我们的文章,可以时常点击文末右下角的「在看」;或者将 脚本之家 星标。

这样操作后,我们每次新的推送才能第一时间出现在你的订阅列表中~


您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存